診所開到現在,替別人量過電池、畫過雷達圖、蓋過章,卻從來沒有人替診所自己做過健檢。
昨天拿手機測完分享功能,我看著那支沒反應的舊手機,突然有種「醫者不能自醫」的荒謬感。給使用者開處方開得很順手,自己家的程式碼放了二十幾天沒人檢查,裡面到底藏了什麼病,我完全沒底。
所以今天診所公休一天,輪到診所自己掛號。
我替自己開了三張檢驗單:
三張單子照順序跑完,這個專題才算有資格拿去部署。
Day 22 整理階段成果的時候,我就發現「算比例、換算百分比」那條公式,在雷達圖、耗損率、六維度歸一化、心電圖振幅四個地方各寫了一份。每份長得都差不多,改其中一份就得記得回頭改另外三份,漏掉一份,畫面上的數字就會對不起來。
整理的做法很樸素:開一個叫 Clinic 的大櫃子,裡面分抽屜,數學放一格、音效放一格,需要的人自己來拿。
const Clinic = (() => {
const math = {
toPercent(value, min, max) { /* 全站唯一一份換算公式 */ }
};
const sound = {
tone(options) { /* 全站唯一一份音效引擎 */ }
};
return { math, sound }; // 只把要給人用的東西露出去
})();
外面包的那層 (() => { ... })() 叫立即執行函式,裡面宣告的變數出了這層就看不到,不會跟別人的變數撞名。對一個沒有打包工具的純前端專題來說,這是最省事的模組化手法。
另外一條路是 <script type="module"> 把檔案拆開,用 import 互相引用。拆檔的結構比較漂亮,但本機直接雙擊 .html 開檔時,瀏覽器會因為安全規則擋掉 module 的載入。今天的示範要能直接存檔就跑,所以先用單檔的 IIFE,等後天掛上 GitHub Pages 之後再決定要不要拆。
昨天那兩個症狀,加上手機上另外兩個常見的老毛病,一共可以整理出三件事:
AudioContext 一開始是睡著的,得在使用者真的碰過畫面的那一刻叫醒它。行動版對「什麼算使用者手勢」還挺挑的,所以我會同時監聽 touchend、click、keydown,哪個先到就用哪個,成功之後立刻把監聽拆掉。touch-action: manipulation,告訴瀏覽器這顆按鈕只管點擊,不用等著判斷是不是雙擊縮放,手感會俐落不少。100vh 算出來的高度常常比實際看得到的區域還高,底部的按鈕被切掉半截。改用 100dvh,它會跟著網址列的伸縮動態調整。比這三招更重要的是一個心態:判斷「能不能用」要靠功能偵測,不要靠猜裝置。 與其寫「如果是 iPhone 就怎樣」,不如直接問瀏覽器「你有沒有 Clipboard API」,有就用,沒有就走備案。裝置型號每年都在變,功能偵測不會過期。
效能這件事,我一開始的直覺是「大概是動畫太多」,後來實際量過才知道,真正的元兇通常更不起眼:
offsetHeight,瀏覽器每一圈都得停下來重新計算版面。一千多圈下來,就是一千多次重算。前者的解法是 DocumentFragment:先在記憶體裡的「暫存盤」把元素排好,最後一次整盤端上桌,版面只算一次。後者用 visibilitychange 事件,頁面被藏起來就暫停,回來再繼續。
今天這份示範把三張檢驗單做成同一頁的三個區塊,每個區塊都能在你自己的裝置上實際跑一次:
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>賽博紓壓診所:院內自主健檢</title>
<style>
* { box-sizing: border-box; }
body {
font-family: 'Segoe UI', Microsoft JhengHei, sans-serif;
background: #e3f2fd;
display: flex;
justify-content: center;
align-items: flex-start;
min-height: 100vh;
min-height: 100dvh; /* 手機網址列會伸縮,dvh 會跟著變 */
margin: 0;
padding: 20px;
}
.card {
background: white;
padding: 22px;
border-radius: 20px;
box-shadow: 0 12px 30px rgba(0,0,0,0.15);
width: 100%;
max-width: 520px;
}
h2 { color: #1e88e5; margin: 0 0 4px; text-align: center; }
.subtitle { color: #546e7a; font-size: 0.85rem; text-align: center; margin-bottom: 16px; }
.sheet {
border: 2px dashed #90caf9;
border-radius: 14px;
padding: 14px;
margin-bottom: 14px;
}
.sheet h3 { margin: 0 0 8px; font-size: 1rem; color: #263238; }
.sheet h3 span { font-size: 0.72rem; color: #90a4ae; margin-left: 6px; font-weight: normal; }
.row {
display: flex;
justify-content: space-between;
gap: 10px;
font-size: 0.85rem;
padding: 5px 0;
border-bottom: 1px dashed #cfd8dc;
color: #37474f;
}
.row:last-of-type { border-bottom: none; }
.ok { color: #2e7d32; font-weight: bold; }
.bad { color: #d32f2f; font-weight: bold; }
.run {
background-color: #1e88e5;
color: white;
border: none;
border-radius: 50px;
padding: 9px 20px;
font-size: 0.85rem;
cursor: pointer;
margin-top: 10px;
touch-action: manipulation; /* 不等雙擊縮放判斷,點擊更俐落 */
-webkit-tap-highlight-color: transparent; /* 拿掉手機點擊時那層灰色閃光 */
}
.run:active { transform: scale(0.96); }
#benchBox {
display: flex;
flex-wrap: wrap;
gap: 2px;
height: 40px;
overflow: hidden;
margin-top: 8px;
}
.dot { width: 8px; height: 8px; background: #90caf9; border-radius: 2px; }
.pulse-track {
height: 10px;
background: #eceff1;
border-radius: 5px;
overflow: hidden;
margin-top: 10px;
}
.pulse-bar { height: 100%; width: 30%; background: #1e88e5; border-radius: 5px; }
.pulse-status { font-size: 0.75rem; color: #90a4ae; margin-top: 4px; }
@media (prefers-reduced-motion: reduce) {
.pulse-bar { display: none; }
}
</style>
</head>
<body>
<div class="card">
<h2>🩺 院內自主健檢</h2>
<p class="subtitle">診所替自己掛號,三張檢驗單</p>
<!-- 檢驗單一:重構 -->
<div class="sheet">
<h3>📁 檢驗單一:病歷室整理<span>重構</span></h3>
<div id="refactorRows"></div>
<button class="run" onclick="runRefactorCheck()">三處共用同一條公式</button>
</div>
<!-- 檢驗單二:相容性 -->
<div class="sheet">
<h3>📱 檢驗單二:換裝置掛號<span>相容性</span></h3>
<div id="compatRows"></div>
<button class="run" onclick="testSound()">試播一聲</button>
<div class="pulse-status" id="audioStatus">音訊狀態:等待第一次觸碰</div>
</div>
<!-- 檢驗單三:效能 -->
<div class="sheet">
<h3>💓 檢驗單三:量院內心跳<span>效能</span></h3>
<div id="benchRows"></div>
<div id="benchBox"></div>
<button class="run" onclick="runBenchmark()">開始量測</button>
<div class="pulse-track"><div class="pulse-bar" id="pulseBar"></div></div>
<div class="pulse-status" id="pulseStatus"></div>
</div>
</div>
<script>
// ================= 檢驗單一:重構 =================
// 用 IIFE 包成一個「大櫃子」,內部變數外面看不到,只露出需要的抽屜
const Clinic = (() => {
const math = {
// 全站唯一一份「換算百分比」公式
toPercent(value, min, max) {
if (max === min) return 50;
const ratio = (value - min) / (max - min);
return Math.max(0, Math.min(100, Math.round(ratio * 100)));
}
};
const sound = {
ctx: null,
// 喚醒(或建立)音訊環境,回傳 Promise
unlock() {
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return Promise.resolve(false);
if (!this.ctx) this.ctx = new AC();
if (this.ctx.state === "suspended") return this.ctx.resume().then(() => true);
return Promise.resolve(true);
},
tone({ type, startFreq, endFreq, duration, volume }) {
try {
if (!this.ctx) return;
const t = this.ctx.currentTime;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(startFreq, t);
osc.frequency.exponentialRampToValueAtTime(endFreq, t + duration);
gain.gain.setValueAtTime(volume, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + duration);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start(t);
osc.stop(t + duration);
} catch (e) {}
}
};
return { math, sound };
})();
function runRefactorCheck() {
// 三個原本各寫各的地方,現在全部呼叫同一個函式
const drain = 100 - Clinic.math.toPercent(14, -5, 24); // 耗損率
const mindset = Clinic.math.toPercent(2, -10, 5); // 心態歸一化
const amplitude = Clinic.math.toPercent(3, 0, 5); // 心電圖進度
document.getElementById("refactorRows").innerHTML = `
<div class="row"><span>耗損率(總分 14,範圍 -5 ~ 24)</span><b>${drain}%</b></div>
<div class="row"><span>心態歸一化(原始 2,範圍 -10 ~ 5)</span><b>${mindset} 分</b></div>
<div class="row"><span>心電圖進度(答 3 題 / 共 5 題)</span><b>${amplitude}%</b></div>`;
}
// ================= 檢驗單二:相容性 =================
// 功能偵測:直接問瀏覽器有沒有,不靠猜裝置型號
const checks = [
{ name: "觸控支援", test: () => "ontouchstart" in window || navigator.maxTouchPoints > 0 },
{ name: "Pointer Events", test: () => "PointerEvent" in window },
{ name: "Web Audio", test: () => !!(window.AudioContext || window.webkitAudioContext) },
{ name: "Clipboard API", test: () => !!(navigator.clipboard && navigator.clipboard.writeText) },
{ name: "安全環境(https / localhost)", test: () => window.isSecureContext },
{ name: "localStorage", test: () => {
try { localStorage.setItem("__t", "1"); localStorage.removeItem("__t"); return true; }
catch (e) { return false; }
}},
{ name: "dvh 單位", test: () => window.CSS && CSS.supports("height", "100dvh") }
];
function renderCompat() {
document.getElementById("compatRows").innerHTML = checks.map(c => {
let ok = false;
try { ok = !!c.test(); } catch (e) {}
return `<div class="row"><span>${c.name}</span><span class="${ok ? "ok" : "bad"}">${ok ? "✅ 支援" : "❌ 不支援"}</span></div>`;
}).join("");
}
// 音訊解鎖:多掛幾種事件,哪個先到就用哪個,成功後立刻拆掉
const unlockEvents = ["touchend", "click", "keydown"];
function unlockOnce() {
Clinic.sound.unlock().then(ok => {
if (!ok) return;
unlockEvents.forEach(ev => document.removeEventListener(ev, unlockOnce));
document.getElementById("audioStatus").innerText = "音訊狀態:已解鎖 ✅";
});
}
unlockEvents.forEach(ev => document.addEventListener(ev, unlockOnce));
function testSound() {
Clinic.sound.unlock().then(() => {
Clinic.sound.tone({ type: "sine", startFreq: 600, endFreq: 200, duration: 0.08, volume: 0.15 });
});
}
// ================= 檢驗單三:效能 =================
const BENCH_COUNT = 2000;
// 壞習慣:每放一個就讀一次版面,逼瀏覽器每一圈都重算
function renderNaive(n) {
const box = document.getElementById("benchBox");
box.innerHTML = "";
const t0 = performance.now();
for (let i = 0; i < n; i++) {
const el = document.createElement("div");
el.className = "dot";
box.appendChild(el);
el.offsetHeight;
}
return performance.now() - t0;
}
// 好習慣:先在暫存盤排好,最後一次端上桌,版面只算一次
function renderBatch(n) {
const box = document.getElementById("benchBox");
box.innerHTML = "";
const t0 = performance.now();
const frag = document.createDocumentFragment();
for (let i = 0; i < n; i++) {
const el = document.createElement("div");
el.className = "dot";
frag.appendChild(el);
}
box.appendChild(frag);
box.offsetHeight;
return performance.now() - t0;
}
function runBenchmark() {
// 先暖機,避免第一次執行比較慢影響比較
renderNaive(200);
renderBatch(200);
const naive = renderNaive(BENCH_COUNT);
const batch = renderBatch(BENCH_COUNT);
const times = naive / Math.max(batch, 0.01);
document.getElementById("benchRows").innerHTML = `
<div class="row"><span>逐筆寫入(每圈強迫重算版面)</span><b>${naive.toFixed(1)} ms</b></div>
<div class="row"><span>批次寫入(Fragment 一次放)</span><b>${batch.toFixed(1)} ms</b></div>
<div class="row"><span>差距</span><b>約 ${times.toFixed(1)} 倍</b></div>`;
}
// 背景分頁暫停動畫:頁面被藏起來就停,回來再繼續
const pulseBar = document.getElementById("pulseBar");
const pulseStatus = document.getElementById("pulseStatus");
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let rafId = null;
let px = -100;
function loop() {
px += 1.5;
if (px > 340) px = -100;
pulseBar.style.transform = `translateX(${px}%)`;
rafId = requestAnimationFrame(loop);
}
function startPulse() {
if (reduceMotion) { pulseStatus.innerText = "系統設定為減少動態,已停用動畫"; return; }
if (!rafId) rafId = requestAnimationFrame(loop);
pulseStatus.innerText = "動畫:執行中(切到別的分頁會自動暫停)";
}
function stopPulse() {
if (rafId) cancelAnimationFrame(rafId);
rafId = null;
}
document.addEventListener("visibilitychange", () => {
if (document.hidden) stopPulse(); else startPulse();
});
// 一打開頁面就把三張檢驗單的初始內容備好
runRefactorCheck();
renderCompat();
startPulse();
</script>
</body>
</html>
再存成 .html 打開,先看第二張檢驗單有哪幾項是紅色的,再按「開始量測」看逐筆跟批次差了幾倍。最好用手機也開一次,同一份程式在兩台裝置上的勾勾跟毫秒數,通常不會一樣。
效能數字每台裝置都不同,只能拿來看相對差距
同一份程式在我電腦上跑出來的毫秒數,跟在手機上差很多,絕對值沒有參考價值。真正有意義的是「逐筆」跟「批次」之間的倍數。另外這個量測只計算 JavaScript 同步執行的時間,不包含畫面真正繪製出來的時間,所以拿來比較寫法的優劣夠用,但別把它當成使用者實際感受到的卡頓時間。
iPhone 側邊的靜音撥片,會連 Web Audio 一起吞掉
據我查到的資料,iOS 上用 Web Audio 發出的聲音會受靜音撥片影響,撥片切到靜音,程式再正確也聽不到。手機測試時要是聽不到聲音,先確認這個撥片,不用急著懷疑程式。
isSecureContext 那一列,決定了 Clipboard API 的命運
Clipboard API 只在安全環境下才開放,也就是 https 或 localhost。這代表等專題掛上 GitHub Pages 的 https 網址之後,才是這個功能真正的考場,目前在本機看到的結果只能當預演。
功能偵測的結果,要有對應的備案
偵測只是第一步,偵測完要有路可走。Day 26 的複製功能就是範例:Clipboard API 不支援,就退回 execCommand。每一項紅色的勾,都應該對應到一個「那我改走哪條路」的答案。
重構的價值,在於縮短下一次修改的路
整理完的程式碼不見得比較漂亮,也不會多出任何新功能。但現在想調整換算公式,只要改 Clinic.math.toPercent 這一個地方,四個畫面一起生效。省下的是「記得回頭改另外三份」這份心理負擔。
相容性的問題,全部住在自己沒測過的裝置裡
電腦上跑得好好的功能,到手機上才露出馬腳。昨天那一輪手機測試,一次就冒出兩個症狀,一個是舊手機的複製功能沒反應,一個是音效要點兩下才出聲。今天做的功能偵測清單,等於是替每台裝置準備一張體檢表,之後拿到任何一支沒見過的手機,打開這頁就知道哪裡會出狀況。
效能要先量,再改
開工前我認定「應該是動畫太多」,結果量出來,最誇張的差距出在那個看起來人畜無害的 offsetHeight。憑直覺猜的瓶頸,很常不是真正的瓶頸。這讓我決定以後覺得「好像有點卡」的時候,先打開量測,再決定要動哪裡。
今天診所替自己做了一次完整的健檢:把重複的公式收進同一個 Clinic 櫃子、用功能偵測替每台裝置列出體檢表、再把 DOM 寫入跟背景動畫的效能問題量過一輪。三張檢驗單跑完,這間診所終於不是只有在我自己的電腦上才站得住。
不過健檢查的是程式碼的體質,使用者實際走過一遍會遇到什麼,單子上看不出來。
所以做完這三張檢驗單,我又關掉編輯器,換成使用者的身分,從首頁開始認真玩了一輪。這一輪玩下來,挑出三個一定要在上線前處理掉的破綻:
三個問題各有各的來歷,今天的健檢報告上完全查不出來,只有真的走過一遍才會發現。
明天,診所繼續公休,我們分三段把這三個破綻一個一個補起來。
把這三個破洞補齊。我們明天見!